package crawler

import (
	"context"
	gerr "errors"
	"gitee.com/gcom/gbox/errors"
	"gitee.com/gcom/stockdock/comm"
	"gitee.com/gcom/stockdock/core/support"
	"github.com/PuerkitoBio/goquery"
	"github.com/chromedp/cdproto/network"
	"github.com/chromedp/cdproto/page"
	"github.com/chromedp/chromedp"
	"io/ioutil"
	"net/url"
	"os"
	"strings"
	"sync"
	"time"
)

const (
	UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36"
)

var (
	DefaultChromedpOpts []chromedp.ExecAllocatorOption
	CrawlErrTimeout     = gerr.New("等待结果超时")
	CrawlErrForbidden   = gerr.New("禁止访问")
)

type CrawlContext interface {
	GetCtx() context.Context
	Cancel()
	Tracer() support.TracerCtx
}

type crawlContext struct {
	tracer support.TracerCtx
	ctx    context.Context
	cancel context.CancelFunc
	l      sync.RWMutex
}

func NewCrawlContext(ctx support.TracerCtx) *crawlContext {
	return &crawlContext{
		tracer: ctx,
	}
}

func (cc *crawlContext) GetCtx() context.Context {
	cc.l.Lock()
	defer cc.l.Unlock()
	if cc.ctx == nil {
		cc.ctx, cc.cancel = NewChromedpCtx(cc.tracer, 0, nil)
	}
	return cc.ctx
}

func (cc *crawlContext) Cancel() {
	cc.l.RLock()
	defer cc.l.RUnlock()
	if cc.cancel != nil {
		cc.cancel()
	}
}

func (cc *crawlContext) Tracer() support.TracerCtx {
	return cc.tracer
}

type CrawlOnValue func(CrawlRequest, string)
type CrawlOnError func(CrawlRequest, error) // Important, the CrawlRequest may be nil when chromedp.Run return error
type CrawlOnDiscardFunc func(CrawlRequest, error)

type CrawlEvalStrategy int

const (
	CrawlEvalOK CrawlEvalStrategy = iota
	CrawlEvalSkipEvent
	CrawlEvalSkipRequest
)

type CrawlRequest interface {
	RequestJs() string
	Timeout() time.Duration
	EvalJs(alertMsg string) (js string, strategy CrawlEvalStrategy)
	OnValue(val string)
	OnError(err error)
	CanRetry(err error) (delay time.Duration, ok bool)
}

func init() {
	DefaultChromedpOpts = append(chromedp.DefaultExecAllocatorOptions[:],
		// 禁用GPU，不显示GUI
		chromedp.DisableGPU,
		// 取消沙盒模式
		chromedp.NoSandbox,
		// 指定浏览器分辨率
		chromedp.WindowSize(1600, 900),
		// 设置UA，防止有些页面识别headless模式
		chromedp.UserAgent(UserAgent),
		// 是否显示浏览器窗口
		chromedp.Flag("headless", true),
		// 禁止弹窗
		chromedp.Flag("disable-popup-blocking", false),
		// 隐身模式启动
		chromedp.Flag("incognito", true),
		// 忽略证书错误
		chromedp.Flag("ignore-certificate-errors", true),
		// 窗口最大化
		chromedp.Flag("start-maximized", true),
		// 不加载图片, 提升速度
		chromedp.Flag("disable-images", true),
		chromedp.Flag("blink-settings", "imagesEnabled=false"),
		// 禁用扩展
		chromedp.Flag("disable-extensions", true),
		// 禁止加载所有插件
		chromedp.Flag("disable-plugins", true),
		// 禁用浏览器应用
		chromedp.Flag("disable-software-rasterizer", true),
		// 设置用户数据目录
		chromedp.Flag("user-data-dir", "./.chrome-cache"),
	)

}

// NewChromedpCtx make default chromedp context
// timeout context timeout in seconds. If timeout <= 0, context will not be timeout
func NewChromedpCtx(tctx support.TracerCtx, timeout int, fn func(ev interface{})) (context.Context, context.CancelFunc) {
	tempDir, err := ioutil.TempDir(".", ".chromedp-")
	if err != nil {
		tctx.Logger.Errorf("创建chromedp临时目录失败，%v", err)
		panic(err)
	}
	tctx.Logger.Infof("创建chromedp临时目录, %s", tempDir)
	// create context
	ctx := context.Background()
	if timeout > 0 {
		ctx, _ = context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
	}
	options := append(DefaultChromedpOpts, chromedp.Flag("user-data-dir", tempDir))
	ctx, _ = chromedp.NewExecAllocator(ctx, options...)
	ctx, cancel := chromedp.NewContext(ctx, chromedp.WithLogf(comm.Log.Infof))
	if fn != nil {
		chromedp.ListenTarget(ctx, fn)
	}
	ctx = context.WithValue(ctx, support.TracerCtxKey, tctx)
	return ctx, func() {
		e := os.RemoveAll(tempDir)
		if e != nil {
			tctx.Logger.Errorf("删除chromedp临时目录失败, %s, %v", tempDir, e)
		}
		tctx.Logger.Infof("删除chromedp临时目录, %s", tempDir)
		cancel()
	}
}

func LoadAndResolveCookie(tctx support.TracerCtx, u *url.URL, timeout int, fn func(ev interface{})) ([]*network.Cookie, error) {
	// create context
	ctx, cancel := NewChromedpCtx(tctx, timeout, fn)
	defer cancel()
	var cookies []*network.Cookie
	var tasks = chromedp.Tasks{
		chromedp.Navigate(u.String()),
		chromedp.WaitReady("document"),
		chromedp.ActionFunc(func(ctx context.Context) error {
			list, er := network.GetAllCookies().Do(ctx)
			if er != nil {
				return er
			}
			cookies = list
			return nil
		}),
	}
	err := Crawl(ctx, tasks)
	return cookies, err
}

func Crawl(ctx context.Context, tasks chromedp.Tasks) error {
	tasks = append([]chromedp.Action{AntiDetectionHeadlessAction, network.Enable()}, tasks...)
	return chromedp.Run(ctx, tasks)
}

func CrawlPage(tctx support.TracerCtx, url string, timeout int) (html string, err error) {
	// create context
	ctx, cancel := NewChromedpCtx(tctx, timeout, nil)
	defer cancel()
	tasks := chromedp.Tasks{
		chromedp.Navigate(url),
		chromedp.OuterHTML("html", &html),
	}
	err = Crawl(ctx, tasks)
	return
}

func CrawlPageWith(ctx context.Context, url string) (html string, err error) {
	err = Crawl(ctx, chromedp.Tasks{
		chromedp.Navigate(url),
		chromedp.OuterHTML("html", &html),
	})
	return
}

func CrawlAndParsePageWith(ctx context.Context, url string) (*goquery.Document, error) {
	html, err := CrawlPageWith(ctx, url)
	if err != nil {
		return nil, errors.Wrap(err, "加载页面失败, url="+url)
	}

	return goquery.NewDocumentFromReader(strings.NewReader(html))
}

func CrawlAndEval(cctx CrawlContext, url string, ch <-chan CrawlRequest) error {
	ctx := cctx.GetCtx()
	var reqRelay = make(chan string)
	defer close(reqRelay)
	chromedp.ListenTarget(ctx, func(ev interface{}) {
		if ev, ok := ev.(*page.EventJavascriptDialogOpening); ok {
			go func() {
				if err := chromedp.Run(ctx, page.HandleJavaScriptDialog(true)); err != nil {
					cctx.Tracer().Logger.Errorf("关闭alert失败, %v", err)
				}

				reqRelay <- ev.Message
			}()
		}
	})

	return Crawl(ctx, chromedp.Tasks{
		chromedp.Navigate(url),
		chromedp.ActionFunc(func(c context.Context) error {
			cctx.Tracer().Logger.Infof("等待5秒钟，让浏览器完成页面加载")
			time.Sleep(5 * time.Second)

			var res interface{}
			var jsonRes string
		nextRequest:
			for r := range ch {
			relayRequest:
				e := chromedp.EvaluateAsDevTools(r.RequestJs(), &res).Do(c)
				if e != nil {
					cctx.Tracer().Logger.Errorf("执行RequestJs脚本失败, %v", e)
					r.OnError(e)
					return nil
				}

			nextEvent:
				select {
				case alert := <-reqRelay:
					js, strategy := r.EvalJs(alert)
					switch strategy {
					case CrawlEvalSkipRequest:
						goto nextRequest
					case CrawlEvalSkipEvent:
						goto nextEvent
					case CrawlEvalOK:
						e := chromedp.EvaluateAsDevTools(js, &jsonRes).Do(c)
						if e != nil {
							cctx.Tracer().Logger.Errorf("执行EvalJs脚本失败, %v", e)
							r.OnError(e)
							return nil
						}
						r.OnValue(jsonRes)
					}
				case <-time.After(r.Timeout()):
					if d, ok := r.CanRetry(CrawlErrTimeout); ok {
						cctx.Tracer().Logger.Errorf("%v后重试", d)
						time.Sleep(d)
						goto relayRequest
					}
					r.OnError(errors.Wrap(CrawlErrTimeout, ""))
					return nil
				}
			}
			return nil
		}),
	})
}

// 反检测Headless
// https://github.com/chromedp/chromedp/issues/396
var AntiDetectionHeadlessAction chromedp.Action = chromedp.ActionFunc(func(ctx context.Context) error {
	_, err := page.AddScriptToEvaluateOnNewDocument(script).Do(ctx)
	return err
})

// see: https://intoli.com/blog/not-possible-to-block-chrome-headless/
const script = `(function(w, n, wn) {
	console.log(navigator.webdriver);

	// Pass the Webdriver Test.
	// chrome 为undefined，Firefox 为false
	//Object.defineProperty(n, 'webdriver', {
	//	get: () => undefined,
	//});
	// 通过原型删除该属性
	delete navigator.__proto__.webdriver;
	console.log(navigator.webdriver);
	
	// Pass the Plugins Length Test.
	// Overwrite the plugins property to use a custom getter.
	Object.defineProperty(n, 'plugins', {
	// This just needs to have length > 0 for the current test,
	// but we could mock the plugins too if necessary.
	get: () =>[
			{filename:'internal-pdf-viewer'},
			{filename:'adsfkjlkjhalkh'},
			{filename:'internal-nacl-plugin'}
		],
	});
	
	// Pass the Languages Test.
	// Overwrite the plugins property to use a custom getter.
	Object.defineProperty(n, 'languages', {
	get: () => ['zh-CN', 'en'],
	});

	// store the existing descriptor
	const elementDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight');
	
	// redefine the property with a patched descriptor
	Object.defineProperty(HTMLDivElement.prototype, 'offsetHeight', {
	  ...elementDescriptor,
	  get: function() {
		if (this.id === 'modernizr') {
			return 1;
		}
		return elementDescriptor.get.apply(this);
	  },
	});

	['height', 'width'].forEach(property => {
	  // store the existing descriptor
	  const imageDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, property);
	
	  // redefine the property with a patched descriptor
	  Object.defineProperty(HTMLImageElement.prototype, property, {
		...imageDescriptor,
		get: function() {
		  // return an arbitrary non-zero dimension if the image failed to load
		  if (this.complete && this.naturalHeight == 0) {
			return 20;
		  }
		  // otherwise, return the actual dimension
		  return imageDescriptor.get.apply(this);
		},
	  });
	});
	
	// Pass the Chrome Test.
	// We can mock this in as much depth as we need for the test.
	w.chrome = {
		runtime: {},
	};
	window.navigator.chrome = {
	  	runtime: {},
	};
	
	// Pass the Permissions Test.
	const originalQuery = wn.permissions.query;
	return wn.permissions.query = (parameters) => (
	parameters.name === 'notifications' ?
	  Promise.resolve({ state: Notification.permission }) :
	  originalQuery(parameters)
	);

})(window, navigator, window.navigator);`
